Skip to content

Spring Security

Spring Security 是一个功能强大且高度可定制的身份验证和访问控制框架。它是用于保护基于 Spring 的应用程序的事实上的标准。

主要特点有:

  • 身份验证 —— Spring Security 为身份验证提供了全面的支持。身份验证是我们验证谁试图访问特定资源的身份的方法。验证用户身份的常用方法是要求用户输入用户名和密码。一旦执行了身份验证,我们就会知道身份并可以执行授权。

  • 漏洞防护 —— Spring Security 提供了针对常见漏洞(例如:CSRF)的保护。只要有可能,默认情况下就启用保护。

启用 Spring Security

保护 Spring 应用程序的第一步是将 spring-boot-starter-security 依赖项添加到构建中。在项目的 pom.xml 文件中,添加以下依赖:

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

现在,启动应用程序并访问主页(或任何页面): (数据来源:自己截得)

将提示使用 HTTP 基本身份验证对话框进行身份验证。要想通过认证,需要提供用户名和密码。用户名是 user。至于密码,它是随机生成并写入了应用程序日志文件。日志条目应该是这样的:

sh
...
2021-07-22 17:20:58.787  INFO 8240 --- [           main] .s.s.UserDetailsServiceAutoConfiguration : 

Using generated security password: a220c23b-d48b-4305-9c34-d465e83f9a3f

...

假设正确地输入了用户名和密码,将被授予对应用程序的访问权。

那么,Spring Boot 为我们做了哪些自动配置呢?主要有三点:

  • 启用 Spring Security 的默认配置,该配置将创建一个名为 springSecurityFilterChainbean,它本质上是一个 Servlet 过滤器。 此 bean 负责应用程序内的所有安全性(保护应用程序 URL,验证提交的用户名和密码,重定向到登录表单等)。

  • 创建一个 UserDetailsServicebean,其中包含用户名 user 和随机生成的密码,该密码将记录到控制台。

  • 针对每个请求,使用 Servlet 容器向名为 springSecurityFilterChainbean 注册过滤器。

可以看到,只需要在项目构建中添加 spring-boot-starter-security,就可以获得以下安全特性:

  • 所有的 HTTP 请求路径都需要认证

  • 不需要特定的角色或权限

  • 一个默认的登录页面

  • 身份验证由 HTTP 基本身份验证提供

  • 只有一个用户,用户名是 user

但是,大多数应用程序的安全需求不止于此。它们可能想:

  • 自定义登录页面进行身份验证

  • 提供注册页面供新用户注册

  • 为不同的请求路径应用不同的安全规则。例如,注册、登录和主页页面根本不需要身份验证。

默认的 SecurityFilterChain 配置

java
package org.springframework.boot.autoconfigure.security.servlet;

import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.SecurityFilterChain;

/**
 * The default configuration for web security. It relies on Spring Security's
 * content-negotiation strategy to determine what sort of authentication to use. If the
 * user specifies their own {@link WebSecurityConfigurerAdapter} or
 * {@link SecurityFilterChain} bean, this will back-off completely and the users should
 * specify all the bits that they want to configure as part of the custom security
 * configuration.
 *
 * @author Madhura Bhave
 */
@Configuration(proxyBeanMethods = false)
@ConditionalOnDefaultWebSecurity
@ConditionalOnWebApplication(type = Type.SERVLET)
class SpringBootWebSecurityConfiguration {

	@Bean
	@Order(SecurityProperties.BASIC_AUTH_ORDER)
	SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
		http.authorizeRequests().anyRequest().authenticated().and().formLogin().and().httpBasic();
		return http.build();
	}

}

前后端分离

配置默认用户和密码方便调试

方式一

properties
spring.security.user.name=user
spring.security.user.password=pass

方式二

java
@Bean
public InMemoryUserDetailsManager inMemoryUserDetailsManager() {
	return new InMemoryUserDetailsManager(User.withUsername("user")
			.password("{noop}pass").roles().build());
}

警告

选择在密码前加上 {noop} 来表示不应该使用编码,这应该只用于测试目的。

禁用 CSRF

java
http.csrf(csrf -> csrf.disable());

未登录返回自定义内容而不是登录页面

java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll());
java
@RestController
@RequestMapping("/login")
public class LoginController {

	@GetMapping
	public String login() {
		return "You need to login.";
	}

}

这种做法虽然返回了自定义的内容,但用浏览器访问就会发现依然会触发 302 重定向。

最佳实践

翻看源码可以发现,在构建 SecurityFilterChain 时,会初始化一个 LoginUrlAuthenticationEntryPoint 实例作为 ExceptionTranslationFilter 过滤 AuthenticationException 的认证方案。

LoginUrlAuthenticationEntryPoint 中的 commence(HttpServletRequest, HttpServletResponse, AuthenticationException) 方法如下:

java
/**
 * Performs the redirect (or forward) to the login form URL.
 */
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
		AuthenticationException authException) throws IOException, ServletException {
	if (!this.useForward) {
		// redirect to login page. Use https if forceHttps true
		String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
		this.redirectStrategy.sendRedirect(request, response, redirectUrl);
		return;
	}
	String redirectUrl = null;
	if (this.forceHttps && "http".equals(request.getScheme())) {
		// First redirect the current request to HTTPS. When that request is received,
		// the forward to the login page will be used.
		redirectUrl = buildHttpsRedirectUrlForRequest(request);
	}
	if (redirectUrl != null) {
		this.redirectStrategy.sendRedirect(request, response, redirectUrl);
		return;
	}
	String loginForm = determineUrlToUseForThisRequest(request, response, authException);
	logger.debug(LogMessage.format("Server side forward to: %s", loginForm));
	RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
	dispatcher.forward(request, response);
	return;
}

可以看到执行策略不是重定向就是转发到 /login 这个地址。既然这样,我们就自定义这个方法,使其直接返回自定义内容。

java
http.exceptionHandling(
		exceptionHandling -> exceptionHandling.authenticationEntryPoint(
				(request, response, authException) -> {
					response.getWriter()
							.write("Note: You need to login.");
				}));

登录成功或失败后返回自定义内容

方式一

java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll()
		.successForwardUrl("/login/success")
		.failureForwardUrl("/login/failure"));
java
@RestController
@RequestMapping("/login")
public class LoginController {

	@PostMapping("/success")
	public String success() {
		return "Login success. Your username is " + SecurityContextHolder
				.getContext().getAuthentication().getName();
	}

	@PostMapping("/failure")
	public String failure() {
		return "Login failure.";
	}

}

方式二

java
http.formLogin(formLogin -> formLogin.loginPage("/login").permitAll()
//				.successForwardUrl("/login/success")
//				.failureForwardUrl("/login/failure")
		.successHandler((HttpServletRequest request,
				HttpServletResponse response,
				Authentication authentication) -> {
			response.getWriter()
					.write("Note: Login success. Your username is "
							+ SecurityContextHolder.getContext()
									.getAuthentication().getName());
		})
		.failureHandler((HttpServletRequest request,
				HttpServletResponse response,
				AuthenticationException exception) -> {
			response.getWriter().write("Note: Login failure.");
		}));

CORS

java
http.cors();
java
@Bean
CorsConfigurationSource corsConfigurationSource() {
	CorsConfiguration configuration = new CorsConfiguration();
	configuration.setAllowedOrigins(Arrays.asList("*"));
	configuration.setAllowedHeaders(Arrays.asList("*"));
	configuration.setAllowedMethods(Arrays.asList("*"));
	UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
	source.registerCorsConfiguration("/**", configuration);
	return source;
}

XMLHttpRequest 或 Fetch 与 CORS 的一个有趣的特性是,可以基于 HTTP cookies 和 HTTP 认证信息发送身份凭证。一般而言,对于跨源 XMLHttpRequest 或 Fetch 请求,浏览器 不会 发送身份凭证信息。如果要发送凭证信息,需要设置 XMLHttpRequest 的某个特殊标志位。

……

将 XMLHttpRequest 的 withCredentials 标志设置为 true,从而向服务器发送 Cookies。如果服务器端的响应中未携带 Access-Control-Allow-Credentials: true,浏览器将不会把响应内容返回给请求的发送者。

上面的内容摘自 MDN

简而言之:

  • 想要跨域携带 Cookie,客户端需要设置 withCredentials = true
  • 想要跨域返回 Cookie,服务端需要设置 Access-Control-Allow-Credentials = true

在 Spring Security 中调整 CorsConfigurationSource 的配置:

java
@Bean
CorsConfigurationSource corsConfigurationSource() {
	CorsConfiguration configuration = new CorsConfiguration();
	configuration.setAllowCredentials(true);
	configuration.setAllowedOrigins(Arrays.asList("*"));
	configuration.setAllowedHeaders(Arrays.asList("*"));
	configuration.setAllowedMethods(Arrays.asList("*"));
	UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
	source.registerCorsConfiguration("/**", configuration);
	return source;
}

发现再次访问会报下面的错误:

log
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*" since that cannot be set on the "Access-Control-Allow-Origin" response header. To allow credentials to a set of origins, list them explicitly or consider using "allowedOriginPatterns" instead.

再次调整 CorsConfigurationSource 配置后如下:

java
@Bean
CorsConfigurationSource corsConfigurationSource() {
	CorsConfiguration configuration = new CorsConfiguration();
	configuration.setAllowCredentials(true);
//		configuration.setAllowedOrigins(Arrays.asList("*"));
	configuration.setAllowedOriginPatterns(
			Arrays.asList("http://localhost:[*]"));
	configuration.setAllowedHeaders(Arrays.asList("*"));
	configuration.setAllowedMethods(Arrays.asList("*"));
	UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
	source.registerCorsConfiguration("/**", configuration);
	return source;
}

Released under the MIT License.